Необходимо провести корректность проведения и оценку результатов A/B-теста для проверки новой рекомендательной системы.
Техническое задание
Название теста: recommender_system_test;
Группы: А (контрольная), B (новая платёжная воронка);
Дата запуска: 2020-12-07;
Дата остановки набора новых пользователей: 2020-12-21;
Дата остановки: 2021-01-04;
Аудитория: 15% новых пользователей из региона EU;
Назначение теста: тестирование изменений, связанных с внедрением улучшенной рекомендательной системы;
Ожидаемое количество участников теста: 6000.
Ожидаемый эффект: за 14 дней с момента регистрации в системе пользователи покажут улучшение каждой метрики не менее, чем на 10%:
1) конверсии в просмотр карточек товаров — событие product_page
2) просмотры корзины — product_cart
3) покупки — purchase.
#блок с импортом всего нужного
import pandas as pd
import seaborn as sns
import datetime as dt
import plotly.express as px
import math as mth
from matplotlib import pyplot as plt
from plotly import graph_objects as go
from scipy import stats as st
from numpy import median, mean
Загрузим датасеты и сразу посмотрим на дубликаты в них.
events = pd.read_csv('/datasets/ab_project_marketing_events.csv')
all_new_users = pd.read_csv('/datasets/final_ab_new_users.csv')
actions = pd.read_csv('/datasets/final_ab_events.csv')
test_users = pd.read_csv('/datasets/final_ab_participants.csv')
display('Таблица events =====================================================================================================')
display(events.head())
events.info()
display('Дубликаты в events', events.duplicated().sum())
display('Таблица all_new_users ==============================================================================================')
display(all_new_users.head())
all_new_users.info()
display('Дубликаты в all_new_users', all_new_users.duplicated().sum())
display('Таблица actions ====================================================================================================')
display(actions.head())
actions.info()
display('Дубликаты в actions', actions.duplicated().sum())
display('Таблица test_users =================================================================================================')
display(test_users.head())
test_users.info()
display('Дубликаты в test_users', test_users.duplicated().sum())
'Таблица events ====================================================================================================='
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 576.0+ bytes
'Дубликаты в events'
0
'Таблица all_new_users =============================================================================================='
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB
'Дубликаты в all_new_users'
0
'Таблица actions ===================================================================================================='
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB
'Дубликаты в actions'
0
'Таблица test_users ================================================================================================='
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
| 3 | 04988C5DF189632E | A | recommender_system_test |
| 4 | 482F14783456D21B | B | recommender_system_test |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB
'Дубликаты в test_users'
0
Полных дубликатов нет, посмотрим на скрытые дубликаты. Датасет events проверять не буду, он маленький и его проще будет посмотреть глазами.
display(all_new_users['region'].unique())
display(all_new_users['device'].unique())
display(actions['event_name'].unique())
display(test_users['group'].unique())
display(test_users['ab_test'].unique())
array(['EU', 'N.America', 'APAC', 'CIS'], dtype=object)
array(['PC', 'Android', 'iPhone', 'Mac'], dtype=object)
array(['purchase', 'product_cart', 'product_page', 'login'], dtype=object)
array(['A', 'B'], dtype=object)
array(['recommender_system_test', 'interface_eu_test'], dtype=object)
Скрытых дубликатов нет.
Пропуски есть в столбце details датасета actions.
Посмотрим на срез по незаполненным строкам, узнаем какие события им соответствуют.
actions.query('details != details').info()
display(actions.query('details != details')['event_name'].unique())
<class 'pandas.core.frame.DataFrame'> Int64Index: 377577 entries, 62740 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 377577 non-null object 1 event_dt 377577 non-null object 2 event_name 377577 non-null object 3 details 0 non-null float64 dtypes: float64(1), object(3) memory usage: 14.4+ MB
array(['product_cart', 'product_page', 'login'], dtype=object)
Здесь отсутствуют события purchase. Узнаем, в заполненных строках остался только этот тип событий?
actions.query('details == details').info()
display(actions.query('details == details')['event_name'].unique())
display(actions.query('details == details')['details'].unique())
<class 'pandas.core.frame.DataFrame'> Int64Index: 62740 entries, 0 to 62739 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 62740 non-null object 1 event_dt 62740 non-null object 2 event_name 62740 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 2.4+ MB
array(['purchase'], dtype=object)
array([ 99.99, 9.99, 4.99, 499.99])
Всё так. Все заполненные строки соответствуют событию purchase, это стоимость покупки в долларах. Других данных в этом столбце нет. Не вижу смысла заполнять в этом случае пропуски.
Скорректируем форматы данных. Корректировка необходима столбцам event_dt датасета actions и first_date датасета all_new_users.
actions['event_dt'] = pd.to_datetime(actions['event_dt'])
all_new_users['first_date'] = pd.to_datetime(all_new_users['first_date'])
display(actions.info())
display(all_new_users.info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null datetime64[ns] 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: datetime64[ns](1), float64(1), object(2) memory usage: 13.4+ MB
None
<class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null datetime64[ns] 2 region 61733 non-null object 3 device 61733 non-null object dtypes: datetime64[ns](1), object(3) memory usage: 1.9+ MB
None
Есть следующие условия теста:
Дата запуска: 2020-12-07;
Дата остановки набора новых пользователей: 2020-12-21;
Дата остановки: 2021-01-04;
Аудитория: 15% новых пользователей из региона EU;
Назначение теста: тестирование изменений, связанных с внедрением улучшенной рекомендательной системы;
Ожидаемое количество участников теста: 6000.
Также, нужно проверить, не совпадает ли время теста в маркетинговыми активностями.
events.sort_values('start_dt')
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 6 | Chinese New Year Promo | APAC | 2020-01-25 | 2020-02-07 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 8 | International Women's Day Promo | EU, CIS, APAC | 2020-03-08 | 2020-03-10 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 7 | Labor day (May 1st) Ads Campaign | EU, CIS, APAC | 2020-05-01 | 2020-05-03 |
| 9 | Victory Day CIS (May 9th) Event | CIS | 2020-05-09 | 2020-05-11 |
| 11 | Dragon Boat Festival Giveaway | APAC | 2020-06-25 | 2020-07-01 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
| 13 | Chinese Moon Festival | APAC | 2020-10-01 | 2020-10-07 |
| 12 | Single's Day Gift Promo | APAC | 2020-11-11 | 2020-11-12 |
| 5 | Black Friday Ads Campaign | EU, CIS, APAC, N.America | 2020-11-26 | 2020-12-01 |
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 10 | CIS New Year Gift Lottery | CIS | 2020-12-30 | 2021-01-07 |
Набор пользователей пришёлся на период между двумя событиями - "Черной пятницей" и Рождеством. А вот срок проведения как раз попадает на второе из них. Возможно, стоит исключить данные по пользователям, совершавшим действия в этот период, то есть зарегистрированным до 11 декабря включительно, но тогда количество пользователей может быть ниже требуемого. При подсчёте количества пользователей это проверим.
Далее проверим пересечение пользователей, отобранных для теста системы рекомендаций и теста интерфейса для польщователей из Европы.
print('Всего пользователей', test_users['user_id'].nunique())
print('Всего пользователей в тесте рекомендаций', test_users.query('ab_test == "recommender_system_test"')['user_id'].nunique())
print('Всего пользователей в тесте интерфейса', test_users.query('ab_test == "interface_eu_test"')['user_id'].nunique())
print('Всего пользователей в двух тестах', test_users.query('ab_test == "recommender_system_test"')['user_id'].nunique() + test_users.query('ab_test == "interface_eu_test"')['user_id'].nunique())
Всего пользователей 16666 Всего пользователей в тесте рекомендаций 6701 Всего пользователей в тесте интерфейса 11567 Всего пользователей в двух тестах 18268
Пользователей, участвующих в двух тестах немного больше 1500, если их исключить, то необходимо количество пользователей не будет набрано. Также, возвращаясь к исключению пользователей по датам регистрации, возникает та же проблема: исключив пользователей за такой большой период, мы не наберём их необходимое число.
Теперь узнаем количество пользователей, входящих в две группы теста.
print('Всего пользователей в тесте рекомендаций', test_users.query('ab_test == "recommender_system_test"')['user_id'].nunique())
print('Всего пользователей в группе А', test_users.query('ab_test == "recommender_system_test" and group == "A"')['user_id'].nunique())
print('Всего пользователей в группе В', test_users.query('ab_test == "recommender_system_test" and group == "B"')['user_id'].nunique())
print('Всего пользователей в двух группах', test_users.query('ab_test == "recommender_system_test" and group == "A"')['user_id'].nunique() + test_users.query('ab_test == "recommender_system_test" and group == "B"')['user_id'].nunique())
Всего пользователей в тесте рекомендаций 6701 Всего пользователей в группе А 3824 Всего пользователей в группе В 2877 Всего пользователей в двух группах 6701
Здесь пересечений нет. Но стоит отметить, что размер контрольной группы заметно больше, чем группа подопытных. Это может влять на результаты теста, учтём это дальше.
И чтобы окончательно убедиться, что пользователи не задвоены, сделаем ещё 1 проверку.
test_users.query('ab_test == "recommender_system_test"').groupby('user_id').agg({'group': 'count'}).sort_values('group').tail()
| group | |
|---|---|
| user_id | |
| 568C35FDE36F7C26 | 1 |
| 568312840175D56A | 1 |
| 5673140CA30564D5 | 1 |
| 552EDDA2930F0B0D | 1 |
| FFF28D02B1EACBE1 | 1 |
Всё хорошо.
Чтобы проверить, все ли пользователи из тестовых групп относятся к региону EU, создадим профили пользователей.
# заберём только интересующих нас пользователей
profiles = test_users.query('ab_test == "recommender_system_test"')[['user_id', 'group']]
# присоединяем информацию о пользователях
profiles = profiles.merge(all_new_users, on='user_id', how='left')
profiles.info()
profiles.head()
<class 'pandas.core.frame.DataFrame'> Int64Index: 6701 entries, 0 to 6700 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 6701 non-null object 1 group 6701 non-null object 2 first_date 6701 non-null datetime64[ns] 3 region 6701 non-null object 4 device 6701 non-null object dtypes: datetime64[ns](1), object(4) memory usage: 314.1+ KB
| user_id | group | first_date | region | device | |
|---|---|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | 2020-12-07 | EU | PC |
| 1 | A7A3664BD6242119 | A | 2020-12-20 | EU | iPhone |
| 2 | DABC14FDDFADD29E | A | 2020-12-08 | EU | Mac |
| 3 | 04988C5DF189632E | A | 2020-12-14 | EU | iPhone |
| 4 | 482F14783456D21B | B | 2020-12-14 | EU | PC |
Для начала проверим регион.
profiles.groupby('region').agg({'user_id': 'count'}).sort_values('user_id', ascending=False)
| user_id | |
|---|---|
| region | |
| EU | 6351 |
| N.America | 223 |
| APAC | 72 |
| CIS | 55 |
Просто уберём лишних пользователей и посмотрим, соответствуют ли даты регистрации пользователей ТЗ.
profiles = profiles.query('region == "EU"')
profiles.info()
display('Самая ранняя дата регистрации: {0}'.format(profiles['first_date'].dt.date.min()))
display('Самая поздняя дата регистрации: {0}'.format(profiles['first_date'].dt.date.max()))
<class 'pandas.core.frame.DataFrame'> Int64Index: 6351 entries, 0 to 6350 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 6351 non-null object 1 group 6351 non-null object 2 first_date 6351 non-null datetime64[ns] 3 region 6351 non-null object 4 device 6351 non-null object dtypes: datetime64[ns](1), object(4) memory usage: 297.7+ KB
'Самая ранняя дата регистрации: 2020-12-07'
'Самая поздняя дата регистрации: 2020-12-21'
Даты соответствуют. Посчитаем процент пользователей, участвующих в тестировании от общего числа пользователей из Европы, зарегистрированных в том же периоде.
display('Процент участников теста от общего числа новых пользователей по Европе: {0:.2%}'.format(profiles['region'].count()/all_new_users.query('region == "EU" and first_date >= "2020-12-07" and first_date <= "2020-12-21"')['region'].count()))
'Процент участников теста от общего числа новых пользователей по Европе: 15.00%'
По этому показателю проходим ровно по величине, установленной ТЗ.
Промежуточный вывод
В целом, Количество собранных данных соответствует ТЗ: количество пользователей, участвующих в тестировании более 6000, они составляют ровно 15% от всех новых пользователей в регионе EU. Даты привлечения соответствуют требуемым.
Стоит отметить два момента:
Постараемся учесть это в дальнеёшем анализе.
Для начала вычленим действия пользователей, соответствующие нашей тестовой группе и добавим информацию о группе и дате регистрации пользователя.
ab_actions = actions[actions['user_id'].isin(profiles["user_id"].unique())].reset_index(drop=True)
ab_actions = ab_actions.merge(profiles[['user_id', 'group', 'first_date']], on='user_id', how='left')
ab_actions.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 23420 entries, 0 to 23419 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 23420 non-null object 1 event_dt 23420 non-null datetime64[ns] 2 event_name 23420 non-null object 3 details 3196 non-null float64 4 group 23420 non-null object 5 first_date 23420 non-null datetime64[ns] dtypes: datetime64[ns](2), float64(1), object(3) memory usage: 1.3+ MB
Для каждого действия посчитаем количество времени, прошедшее с даты регистрации пользователя и отсеем те, которые сделаны более 14 дней после регистрации.
ab_actions['event_interval'] = ab_actions['event_dt'] - ab_actions['first_date']
ab_actions = ab_actions.query('event_interval <= "14 days"')
ab_actions.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 22620 entries, 0 to 23417 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 22620 non-null object 1 event_dt 22620 non-null datetime64[ns] 2 event_name 22620 non-null object 3 details 3103 non-null float64 4 group 22620 non-null object 5 first_date 22620 non-null datetime64[ns] 6 event_interval 22620 non-null timedelta64[ns] dtypes: datetime64[ns](2), float64(1), object(3), timedelta64[ns](1) memory usage: 1.4+ MB
Исключили 800 штук.
Думаю, можно приступать к анализу.
print('Количество событий: {0}'.format(ab_actions['event_name'].count()))
print('Количество пользователей: {0}'.format(ab_actions['user_id'].nunique()))
print('Среднее количество событий на одного пользователя: {0:.2}'.format(ab_actions['event_name'].count()/ab_actions['user_id'].nunique()))
Количество событий: 22620 Количество пользователей: 3481 Среднее количество событий на одного пользователя: 6.5
Итого, из 6351 пользователя, попавшего в тестовую группу, только 3481 совершили хотя бы одно действие. Всего ими сделано 22620 действий, в среднем 6,5 на человека.
Посмотрим на распределение действий во времени.
ab_actions['event_dt'].hist(bins=23, figsize=(15, 5))
plt.title('Распределение событий по датам')
plt.xlabel("Дата")
plt.ylabel("Количество событий")
plt.show()
До 13 числа количество действий пользователей довольно стабильно. 14 декабря происходит резкий всплеск активности с повышением до пика 21 декабря. После этого количество действий идёт на спад.
Посмотрим на распределение действий по пользователям.
ab_actions.groupby('user_id').agg({'event_name': 'count'}).hist(bins=23, figsize=(15, 5))
plt.title('Распределение действий по пользователям')
plt.xlabel("Количество действий")
plt.ylabel("Количество ползователей")
plt.show()
На графике много провалов, но отдалённо угадывается "колокол" нормального распределения с пиком на 6 действиях на 1 пользователя. В целом очень больших выбросов я не вижу, считаю, что исключать данные не нужно.
Посмотрим всё описанное выше в разрезе групп.
for group in ab_actions['group'].unique():
print('Количество событий в группе {0}: {1}'.format(group, ab_actions.query('group == @group')['event_name'].count()))
print('Количество активных пользователей в группе {0}: {1}'.format(group, ab_actions.query('group == @group')['user_id'].nunique()))
print('Среднее количество событий на одного пользователя в группе {0}: {1:.2}'.format(group, ab_actions.query('group == @group')['event_name'].count()/ab_actions.query('group == @group')['user_id'].nunique()))
Количество событий в группе A: 17835 Количество активных пользователей в группе A: 2604 Среднее количество событий на одного пользователя в группе A: 6.8 Количество событий в группе B: 4785 Количество активных пользователей в группе B: 877 Среднее количество событий на одного пользователя в группе B: 5.5
Увеличилась разница между группами. Активных пользователей в контрольной группе на 1700 больше. И действий они генерируют тоже больше - около 80% всех действий сделано пользователями контрольной группы, в среднем 6,8 действий на пользователя против 5,5 у подопытной группы.
for group in ab_actions['group'].unique():
ab_actions.query('group == @group')['event_dt'].hist(bins=23, figsize=(15, 5))
plt.title('Распределение событий по датам по группе {0}'.format(group))
plt.xlabel("Дата")
plt.ylabel("Количество событий")
plt.show()
Заметно, что основную массу действий сгенерировали пользователи из тестовой группы, форма графика сильно похожа на общий график. У подопытной группы и порядок чисел меньше и количество действий распределено более равномерно, но также поле 21 декабря идёт спад.
for group in ab_actions['group'].unique():
ab_actions.query('group == @group').groupby('user_id').agg({'event_name': 'count'}).hist(bins=23, figsize=(15, 5))
plt.title('Распределение действий по пользователям группы {0}'.format(group))
plt.xlabel("Количество действий")
plt.ylabel("Количество ползователей")
plt.show()
У обеих групп пользователей распределение похоже и в целом повторяет форму общего графика.
Сначала посмотрим общую информацию по распределению событий по типам.
ab_actions.groupby('event_name').agg({'user_id': 'count'}).sort_values(by = 'user_id', ascending=False)
| user_id | |
|---|---|
| event_name | |
| login | 10217 |
| product_page | 6322 |
| purchase | 3103 |
| product_cart | 2978 |
Всего четыре вида событий: авторизация, открытие страницы товара, открытие корзины, и завершение покупки.
Больше всего раз пользователи авторизовались - это событие занимает почти половину всех событий за исследуемый период.
Что занятно, событие завершения покупки сделано больше раз, чем переход в корзину. Судя по всему, у пользователей есть возможность оплачивать товар без посещения корзины.
Посмотрим на количество пользователей по событиям. Сразу посчитаем долю от всех зарегистированных пользователей.
event_users_all = ab_actions.groupby('event_name').agg({'user_id': 'nunique'}).sort_values(by = 'user_id', ascending=False)
event_users_all['part'] = round(event_users_all['user_id'] / profiles['user_id'].nunique()*100, 2)
event_users_all = event_users_all.reindex(['login', 'product_page', 'product_cart', 'purchase'])
display(event_users_all)
fig = go.Figure(go.Funnel(x = event_users_all['user_id'], y = event_users_all.index))
fig.update_layout(title_text='Воронка событий для общей тестовой группы',
width=800,
height=600)
fig.show()
| user_id | part | |
|---|---|---|
| event_name | ||
| login | 3480 | 54.79 |
| product_page | 2178 | 34.29 |
| product_cart | 1026 | 16.15 |
| purchase | 1082 | 17.04 |
После регистрации авторизовалась немного больше половины пользователей. Треть от общего числа посмотрела карточку товара, 16 процентов просмотрело корзину и немного больше, 17% завершило покупку.
Рассмотрим ту же воронку в разрезе групп.
event_users_ab = ab_actions.query('group == "A"').groupby('event_name').agg({'user_id': 'nunique'}).sort_values(by = 'user_id', ascending=False).reindex(['login', 'product_page', 'product_cart', 'purchase']).reset_index().rename(columns={'user_id': 'A'})
event_users_ab['part_A'] = round(event_users_ab['A'] / profiles.query('group == "A"')['user_id'].nunique()*100, 2)
event_users_ab = event_users_ab.merge(ab_actions.query('group == "B"').groupby('event_name').agg({'user_id': 'nunique'}).reindex(['login', 'product_page', 'product_cart', 'purchase']).reset_index(), on='event_name', how='left').rename(columns={'user_id': 'B'})
event_users_ab['part_B'] = round(event_users_ab['B'] / profiles.query('group == "B"')['user_id'].nunique()*100, 2)
display(event_users_ab)
fig = go.Figure(go.Funnel(x = event_users_ab['A'], y = event_users_ab['event_name']))
fig.update_layout(title_text='Воронка событий для группы A',
width=800,
height=600)
fig.show()
fig = go.Figure(go.Funnel(x = event_users_ab['B'], y = event_users_ab['event_name']))
fig.update_layout(title_text='Воронка событий для группы B',
width=800,
height=600)
fig.show()
| event_name | A | part_A | B | part_B | |
|---|---|---|---|---|---|
| 0 | login | 2604 | 71.66 | 876 | 32.24 |
| 1 | product_page | 1685 | 46.37 | 493 | 18.15 |
| 2 | product_cart | 782 | 21.52 | 244 | 8.98 |
| 3 | purchase | 833 | 22.92 | 249 | 9.16 |
Хорошо заметно, что в подопытной группе доля пользователей, авторизовавшихся в система в два раза меньше, чем у контрольной. Но при этом пользователи переходят по цепочке действий очень похожим образом. Доли пользователей, переходящих на следующий этап практически те же.
Промежуточный вывод
В среднем, немногим больше половины пользователей (54.79%) тестовой группы авторизовались в системе после регистрации. Если смотреть по группам, различия между ними только усиливаются: из 3824 пользователей группы А хотя бы один раз авторизовалось 2604, около 70%, в то время как из 2877 пользователей группы В авторизовалось только 876, только треть.
Но при этом, авторизовавшиеся пользователи ведут себя похожим образом: и распределение действий по пользователям между группами похожее и доли перехода по цепочке действий в приложении практически совпадают.
Составим функцию для расчета p_value.
Сразу примем уровень значимости равным 0,05, но стоит учесть, что при сравнении групп мы будем проводить сравнение 4 раза - для каждого события в цепочке, чтобы снизить вероятность ошибки, предлагаю скорректировать его по методу Шидака.
alpha = 1 - (1 - 0.05) ** (1 / 4)
distr = st.norm(0, 1)
def calc_prop_eq(event):
t1 = profiles.query('group == "A"')['user_id'].nunique()
t2 = profiles.query('group == "B"')['user_id'].nunique()
s1 = ab_actions.query('group == "A" and event_name == @event')['user_id'].nunique()
s2 = ab_actions.query('group == "B" and event_name == @event')['user_id'].nunique()
p1 = s1 / t1
p2 = s2 / t2
p_comb = (s1 + s2) / (t1 + t2)
z_value = (p1 - p2) / mth.sqrt(p_comb * (1 - p_comb) * (1/t1 + 1/t2))
p_value = (1 - distr.cdf(abs(z_value))) * 2
growth = (p2 / p1 - 1) * 100
return p_value, p1, p2, growth
Приступим к сравнению двух наших групп. Нулевая гипотеза для проверки - доли пользователей, совершивших определённое действие для групп А и В не различается.
for i in event_users_all.index:
event_p_val = calc_prop_eq(i)
print('Доля пользователей, совершивших событие {0} для группы А: {1}'.format(i, round(event_p_val[1], 2)))
print('Доля пользователей, совершивших событие {0} для группы В: {1}'.format(i, round(event_p_val[2], 2)))
print('Прирост доли пользователей, совершивших событие {0} для групп А и В: {1}'.format(i, round(event_p_val[3], 2)))
print('p-значение по событию {0} для групп А и В: {1}'.format(i, round(event_p_val[0], 2)))
if (event_p_val[0] < alpha):
print("Отвергаем нулевую гипотезу: между долями есть значимая разница")
else:
print("Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными")
print('======================================================================================================')
Доля пользователей, совершивших событие login для группы А: 0.72 Доля пользователей, совершивших событие login для группы В: 0.32 Прирост доли пользователей, совершивших событие login для групп А и В: -55.01 p-значение по событию login для групп А и В: 0.0 Отвергаем нулевую гипотезу: между долями есть значимая разница ====================================================================================================== Доля пользователей, совершивших событие product_page для группы А: 0.46 Доля пользователей, совершивших событие product_page для группы В: 0.18 Прирост доли пользователей, совершивших событие product_page для групп А и В: -60.87 p-значение по событию product_page для групп А и В: 0.0 Отвергаем нулевую гипотезу: между долями есть значимая разница ====================================================================================================== Доля пользователей, совершивших событие product_cart для группы А: 0.22 Доля пользователей, совершивших событие product_cart для группы В: 0.09 Прирост доли пользователей, совершивших событие product_cart для групп А и В: -58.27 p-значение по событию product_cart для групп А и В: 0.0 Отвергаем нулевую гипотезу: между долями есть значимая разница ====================================================================================================== Доля пользователей, совершивших событие purchase для группы А: 0.23 Доля пользователей, совершивших событие purchase для группы В: 0.09 Прирост доли пользователей, совершивших событие purchase для групп А и В: -60.02 p-значение по событию purchase для групп А и В: 0.0 Отвергаем нулевую гипотезу: между долями есть значимая разница ======================================================================================================
Промежуточный вывод
Можно с уверенностью сказать, что тест не достиг ожидаемого эффекта. Конверсия подопытной группы не только не увеличилась, но сильно снизилась по отношению к тестовой группе по всем интересующим нас действиям, а нулевое p-значение по всем проверкам только подтверждает это.
В целом, пользователи в тестовых группах ведут себя похоже за одним исключением - пользователи из группы подопытных намного реже авторизуются в системе, в связи с чем генерируют заметно меньше действий.
Тестирование новой рекомендательной системы не достигло ожидаемого результата - вместо роста конверсии зарегистрированных пользователей видно сильно её снижение.
При этом, несмотря на формально соответствие ТЗ, считать тест проведённым корректно я не могу. На результат могло повлиять несколько факторов:
Рекомендую пересмотреть время проведения и подход к набору пользователей в тестовую группу, после чего провести повторное тестирование.